Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: Asynchronous burn reentrancy bug #17

Draft
wants to merge 4 commits into
base: main
Choose a base branch
from

Conversation

silasdavis
Copy link
Contributor

@silasdavis silasdavis commented Nov 19, 2024

Original report:

Hello, I think there may be an issue with the ConfidentialERC20Wrapper (https://github.com/Inco-fhevm/confidential-erc20-framework/blob/main/contracts/ConfidentialERC20Wrapper.sol).It seems possible to transfer funds while the Gateway is pending decryption (https://github.com/Inco-fhevm/confidential-erc20-framework/blob/main/contracts/ConfidentialERC20/ConfidentialERC20.sol#L247). Since the underflow is checked only when the unwrap function is called, it would be possible to steal all the ERC20 wrapped in the contract.For instance, let's say my encrypted balance is equal to 1. I call the unwrap() function with amount=1. Before the Gateway decrypts (and calls the callback function _burnCallback), I transfer my 1 to another address. Once the gateway calls the fallback, it would make my balance equal to type(uint64).max . Then, I can check the (decrypted) totalSupply and call another unwrap() passing amount = totalSupply . Once the gateway calls the callback (_burnCallback), it would transfer all the tokens to me

@silasdavis
Copy link
Contributor Author

Opening for discussion, currently untested.

@@ -109,8 +114,7 @@ abstract contract ConfidentialERC20 is Ownable, IConfidentialERC20, IERC20Metada
function transfer(address to, euint64 value) public virtual returns (bool) {
require(TFHE.isSenderAllowed(value));
address owner = _msgSender();
ebool isTransferable = TFHE.le(value, _balances[msg.sender]);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did not like this essential, universal check being the responsibility of the caller, so it is moved to _transfer and the logic is centralised in _hasSufficientBalance which also considers the _lockedBalances

BurnRq memory burnRequest = burnRqs[requestID];
address account = burnRequest.account;
uint64 amount = burnRequest.amount;
if (!decryptedInput) {
revert("Decryption failed");
Copy link
Contributor Author

@silasdavis silasdavis Nov 19, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was a bit of a misleading error message


ebool allowedTransfer = TFHE.le(amount, currentAllowance);

ebool canTransfer = TFHE.le(amount, _balances[owner]);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_transfer now handles the balance check

Original report:

>>>�Hello, I think there may be an issue with the ConfidentialERC20Wrapper (https://github.com/Inco-fhevm/confidential-erc20-framework/blob/main/contracts/ConfidentialERC20Wrapper.sol).It seems possible to transfer funds while the Gateway is pending decryption (https://github.com/Inco-fhevm/confidential-erc20-framework/blob/main/contracts/ConfidentialERC20/ConfidentialERC20.sol#L247). Since the underflow is checked only when the unwrap function is called, it would be possible to steal all the ERC20 wrapped in the contract.For instance, let's say my encrypted balance is equal to 1. I call the unwrap() function with amount=1. Before the Gateway decrypts (and calls the callback function _burnCallback), I transfer my 1 to another address. Once the gateway calls the fallback, it would make my balance equal to type(uint64).max . Then, I can check the (decrypted) totalSupply and call another unwrap() passing amount = totalSupply . Once the gateway calls the callback (_burnCallback), it would transfer all the tokens to me

Signed-off-by: Silas Davis <[email protected]>
@silasdavis silasdavis force-pushed the sd/fix-burn-reentrancy branch from e97c8ea to 26300f4 Compare November 19, 2024 11:17

error BurnRequestDoesNotExist(uint256 requestId);

function _cancelBurn(uint256 requestId) internal virtual {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could be exposed to the original sender/unwrapper if they wanted to cancel in-flight. We ought to be able to rely on the callback being called though, so we shouldn't need this just for cleaning up in-flight burns.

The EOA cna use the BurnRequested event to keep track of this requestId so it's possible to give them cancellation

@DevSwayam
Copy link
Contributor

If the request fails, the user will cancel it and retry?

@DevSwayam
Copy link
Contributor

DevSwayam commented Nov 20, 2024

The issue with the requestBurn function is that it updates the locked balance before verifying the user has sufficient balance, causing a failure in every case. This violates the checks-effects-interactions principle.

Link to relevant code - requestBurn

Signed-off-by: Silas Davis <[email protected]>
@silasdavis silasdavis force-pushed the sd/fix-burn-reentrancy branch from 9219c5c to 93c598e Compare November 21, 2024 10:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants